# Forum API

from deps.Base import Mergeable, Data
from deps.BeautifulSoup import BeautifulSoup
from deps.File import File as BaseFile
from deps.Form import Form
from deps.Hooks import Hooks
from deps.PageRequest import PageRequest
from deps.Settings import Settings as BaseSettings, ConfigParser

from copy import copy
from html.entities import name2codepoint
import pickle
import os
import re
import sys
import urllib.parse

forum_types = ["vanilla", "vbulletin"]
__all__ = [
	# Variables
	"forum_types",
	
	# Exceptions
	"ForumAPIException", "NotImplementedException", "ModuleError",
	"UnknownError", "SessionError", "PermissionError", "DuplicateError",
	"UserError",
	
	# Properties
	"hooks",
	
	# Utilities
	"data_file", "pickle_data", "unpickle_data",
	"convert_entities", "remove_tags",
	"get_cookies", "encode_cookies", "qs_dict_from_url",
	"start",
	
	# Classes
	"File", "Settings",
	"Site",
	
	# Extensible objects
	"ExtensibleObject",
	"Language", "Session", "User", "Post", "Thread", "Forum", "PreparedPost"
] + forum_types

__version__ = "0"

## Exceptions ##

class ForumAPIException(Exception):
	"""The parent of all custom exceptions raised in this module."""

class NotImplementedException(ForumAPIException):
	"""Indicates an attempt to use a feature that is not yet implemented."""

class ModuleError(NotImplementedException):
	"""Indicates a nonexistent or unsupported module."""

class UnknownError(ForumAPIException):
	"""Indicates an unrecognized response from the server."""

class LanguageError(ForumAPIException):
	"""Indicates a nonexistent or malformed language."""

class SessionError(ForumAPIException):
	"""Indicates a failed login attempt."""

class PermissionError(ForumAPIException):
	"""Indicates that an action was attempted which the user did not have the
	required permissions to perform."""

class DuplicateError(ForumAPIException):
	"""Indicates an attempt to post duplicate content."""

class UserError(ForumAPIException):
	"""Indicates an attempt to find a user that doesn't exist."""

## Properties ##

hooks = Hooks()

PageRequest.user_agent = "vbbot/{0}".format(__version__)

_module = None
_auth_types = ["pwfile", "sessionfile", "hashfile", "cookiefile", "input", "session", "hash"]

## Hooks ##

def _form_submit(form=None, post_data=None, **kwargs):
	if "session" in form.kw:
		return(form.kw["session"].get_page(form.action, method=form.method, post_data=post_data))

Form.hooks.add("form_submit", _form_submit)

## Utilities ##

def _flatten_data(attr):
	if attr is None:
		return(None)
	elif isinstance(attr, Data):
		data = attr.data
		if not isinstance(data, dict):
			return(data)
	elif isinstance(attr, (bool, int, str)):
		return(attr)
	elif isinstance(attr, dict):
		return(dict([(name, _flatten_data(value)) for (name, value) in attr.items]))
	elif isinstance(attr, tuple):
		return(tuple([_flatten_data(value) for value in attr]))
	
	try:
		return(int(attr))
	except TypeError:
		pass
	
	try:
		return(str(attr))
	except TypeError:
		pass
	
	try:
		return([_flatten_data(a) for a in attr])
	except TypeError:
		return(None)

def data_file(name, extension):
	"""Given a file name and extension, returns a relative path to a data file.
	
	Intended to stop hardcoding the path all over the place."""
	
	return "data/{0}.{1}".format(name, extension)

def pickle_data(name, data):
	"""Given a file name without extension, stores the data in a pickled
	binary format in the file using the extenion "pickle"."""
	
	with open(data_file(name, "pickle"), mode="wb") as f:
		pickle.dump(data, f)

def unpickle_data(name):
	"""Given a file name without extension, returns the object created from
	unpickling the data in the file."""
	
	if os.access(data_file(name, "pickle"), os.R_OK):
		with open(data_file(name, "pickle"), mode="rb") as f:
			return(pickle.load(f))

def convert_entities(text):
	"""Converts most XML entities to their character representations."""
	
	return(re.sub("&(#\d+|#x[0-9a-fA-F]+|\w+);", _convert_entity, text))

def _convert_entity(match):
	"""Helper function of `convert_entities`."""
	
	entity = match.group(1)
	if entity in name2codepoint:
		return(chr(name2codepoint[entity]))
	elif entity[0] == "#":
		num = entity[1:]
		if num.isdigit():
			return(chr(int(num)))
		elif num[0] == "x":
			hex = num[1:]
			try:
				return(chr(int(hex, 16)))
			except:
				return("&%s;" % x)

def remove_tags(soup):
	"""Given a `BeautifulSoup`, recursively removes all tags and returns a 
	string of the contents of the soup."""
	
	if isinstance(soup, str):
		return(soup)
	elif soup.string:
		return(str(soup.string))
	else:
		ret = ""
		for tag in soup.contents:
			ret += remove_tags(tag)
		
		return(ret)

def get_cookies(response):
	"""Given a response returned from a `PageRequest`, obtain any `Set-Cookie`
	HTTP headers and return a dictionary with the cookies values keyed to their
	names.
	
	Note that this disregards expiry times, etc."""
	
	cookies = {}
	
	cookie_headers = response.response.info().get_all("Set-Cookie")
	if cookie_headers:
		for set_header in cookie_headers:
			key,_,value = set_header.partition(";")[0].partition("=")
			key = urllib.parse.unquote(key)
			value = urllib.parse.unquote(value)
			
			cookies[key] = value
	
	return(cookies)

def encode_cookies(dictionary):
	"""Given a dictionary, encodes said dictionary as a string for use in a
	`Cookie` HTTP header."""
	
	return(";".join("{0}={1}".format(urllib.parse.quote(str(key)), urllib.parse.quote(str(value))) for (key, value) in dictionary.items()))

def qs_dict_from_url(url):
	"""Given a URL, returns a dictionary of the variables in the query string
	keyed to their name."""
	
	parts = urllib.parse.urlparse(url)
	qs = urllib.parse.parse_qs(parts.query)
	return(qs)

def run(func, options=None, args=None, supported_types=[]):
	"""Runs a function, passing to it the objects that are required to set up a
	connection to the forum through the forum API. Cleans up and exits
	afterwards. This is the recommended way to set up forumapi.
	
	The `options` parameter may either be a list of options, with any number of
	option strings and optionaly the option attributes as their last value, or
	a function, which will be passed the `Settings` object. That function may
	either add the script's options itself, or return a list to be added in the
	same way as above, or both.
	
	Similartly, the `args` parameter can be a list of dictionaries,	each
	containing at least a "name" key.
	
	The `supported_types` list will be passed to the `Site` object, where it is
	checked against the forumapi's module name; if that forum type is not in the
	list, it will raise a `ModuleError`.
	
	The value that the `func` parameter returns when called is used as the exit
	status of the program."""
	
	settings = Settings()
	if isinstance(options, list):
		settings.add_options(options)
	elif options is not None:
		opts = options(settings)
		if opts:
			settings.add_options(opts)
	
	if isinstance(args, list):
		settings.add_args(args)
	
	session = _setup(settings, supported_types)
	ret = func(session, settings.options, settings.args)
	session.close()
	
	sys.exit(ret)

def start():
	"""Creates the objects that are required to set up a connection to the
	forum through the forum API, and returns the `Session` object.
	
	Mostly intended to simplify testing from the python interactive mode."""
	
	settings = Settings()
	return(_setup(settings))

def _setup(settings, supported_types=[]):
	site = Site(settings, supported_types)
	return(Session.from_options(site, settings.options))

## Classes ##

class File(BaseFile):
	@property
	def data(self):
		data = super().data
		if "session" in data:
			del data["session"]
		
		return(data)
	
	def get_page(self):
		if "session" in self.kw:
			return(self.kw["session"].get_page(self.url))
		else:
			return(super().get_page())

class Settings(BaseSettings):
	def __init__(self, settings_file=None):
		if not settings_file:
			settings_file = "settings"
		
		settings_file = data_file(settings_file, "cfg")
		
		super().__init__(settings_file)
		
		self.add_standard_options()
	
	def add_standard_options(self):
		"""Defines default options that every script will want to use, even if
		the script only requires guest access."""
		
		self.add_option("--user", conf="user", help="user ID or name, depending on --auth")
		# Don't show default for password
		self.add_option("--password", conf="password", type="password", hide_default=True, help="password file or raw password, depending on --auth", metavar="PW")
		self.add_option("--auth", type="choice", choices=_auth_types, conf="auth", help="authentication type", metavar="TYPE")
		self.add_option("--save-cookies", type="bool", default=False, help="create a cookie file")
		self.add_option("--delay", type="int", conf="request_delay", dest="request_delay", help="number of seconds delay between requests", metavar="SEC")
		self.add_option("--verbose", type="bool", conf="verbose", help="output verbose messages")
		self.add_option("--instances", type="bool", conf="track_instances", help="track object instances")
		self.add_option("--session", dest="instances_file", help="file to read from and/or write to, the instances that were used or should be reused in this session")
		self.add_option("--language", conf="language", help="language the site will be in")

class Module(object):
	MAX_ATTR_RECURSION = 3
	_attr_recursion = 0
	
	def __init__(self, site, supported_types=[]):
		"""Given a Site object, sets up and imports the API module to use for
		that site."""
		
		if site.type not in forum_types:
			raise(ModuleError("Forum type is not supported in the forum API."))
		
		if isinstance(supported_types, list) and len(supported_types) > 0 and site.type not in supported_types:
			raise(ModuleError("Forum type is not supported by the script."))
		
		self.name = self.__class__.__module__ + "." + site.type
		
		# Import it now
		__import__(self.name)
	
	def get_class(self, cls):
		"""Given a class, tries to use the module's class if it has one.
		Otherwise, it will return the class as defined in this file.
		
		Only use this with a class that is defined in this module, and do not
		give a class name string. Use `Module.get_attr` in the latter case."""
		
		if cls.__module__ != self.__class__.__module__:
			# Class that is outside forumapi, maybe the module itself
			return(cls)
		
		module_cls = self.get_attr(cls.__name__)
		if module_cls:
			return(module_cls)
		else: 
			return(cls)
	
	def get_attr(self, attr):
		"""Given an attribute name, returns the module's attribute if it has
		one by that name (or an alias that the module defined), or `None` if it
		doesn't."""
		
		if self._attr_recursion > self.MAX_ATTR_RECURSION:
			return(None)
		
		self.__class__._attr_recursion += 1
		ret = None
		
		if hasattr(sys.modules[self.name], attr):
			ret = getattr(sys.modules[self.name], attr)
		else:
			try:
				__import__(self.module + "." + attr)
				ret = self.get_attr(attr)
			except ImportError:
				pass
			
			if ret is None:
				list_attr = self.get_attr_from_list(attr)
				if list_attr:
					ret = self.get_attr(list_attr)
		
		self.__class__._attr_recursion -= 1
		return(ret)
	
	def get_attr_from_list(self, attr):
		"""Returns the alias that the module has defined for the given attribute
		name, or `None` if no such alias could be found."""
		
		if hasattr(sys.modules[self.name], "__list__"):
			if attr in sys.modules[self.name].__list__:
				return(sys.modules[self.name].__list__[attr])
			else:
				return(None)
		else:
			return(None)
	
	def __str__(self):
		return(self.name)
	
	def __repr__(self):
		return("<Module \"{self.name}\">".format(self=self))

class Site(Data):
	"""It's a forum site. It knows its URL, type, encoding and cookie prefix."""
	
	def __init__(self, settings, supported_types=[]):
		"""Sets site variables, such as the URL to the site, the encoding the
		pages are in, and the cookie prefix that is to be used for cookies.
		
		These can all be defined in the settings file."""
		
		self.settings = settings
		
		options = self.settings.get_settings_section("site")
		if "url" in options:
			if options["url"][-1] == "/":
				self.url = options["url"]
			else:
				self.url = options["url"] + "/"
		else:
			raise(ValueError("Site URL must be defined in the settings."))
		
		if "type" in options:
			self.type = options["type"].lower()
		else:
			self.type = "vbulletin"
		
		if "encoding" in options:
			self.encoding = options["encoding"]
		else:
			self.encoding = "ISO-8859-1"
		
		if "cookie_prefix" in options:
			self.cookie_prefix = options["cookie_prefix"]
		else:
			self.cookie_prefix = "bb"
		
		self.module = Module(self, supported_types)
	
	@property
	def data(self):
		return({
			"url": self.url,
			"type": self.type,
			"encoding": self.encoding,
			"cookie_prefix": self.cookie_prefix,
		})
	
	def __repr__(self):
		return("<Site url=\"{self.url}\" encoding=\"{self.encoding}\" cookie_prefix=\"{self.cookie_prefix}\">".format(self=self))

class ExtensibleObject(Mergeable, Data):
	"""Allows deferring class instantiations to the forum type module.
	
	Also allows merging objects based on their equality."""
	
	_attr_recursion = 0
	
	@classmethod
	def obj(cls, *a, **kw):
		"""Returns an object of the class.
		
		This can be overridden in order to alter the parameters or to return
		an object from the parameters. The function can be used to ensure that
		a variable is an object, even if a string or integer was given.
		
		An inheriting class could define itself as in the following example:
		class User(ExtensibleObject):
			def obj(cls, session, *a, **kw):
				if isinstance(a[0], cls):
					return(a[0])
				elif isinstance(a[0], int):
					return(cls(session, *a[1:], id=a[0], **kw))
				elif isinstance(a[0], str):
					return(cls(session, *a[1:], name=a[0], **kw))
				else:
					return(None)
			
			def __init__(self, session, id=None, name=None):
				...
		
		The obj function can be called regardless of the type of the second
		parameter: `User.obj(session, user)` would return `user` if the 
		parameter already is a `User` object. Otherwise, it would create a
		`User` object, with `user` being the value of the `id` parameter in
		case of an integer, or the `name` parameter in case of a string."""
		
		if len(a) > 1 and isinstance(a[1], cls):
			return(a[1])
		else:
			return(cls(*a, **kw))
	
	def __new__(cls, *a, **kw):
		"""Gives the object instantiatior the module's class if there is one."""
		
		if len(a) > 0 and isinstance(a[0], (ExtensibleObject, Site)):
			return(super().__new__(a[0].module.get_class(cls)))
		else:
			return(super().__new__(cls))
	
	@property
	def module(self):
		if "session" not in self.__dict__:
			raise(ForumAPIException("Object needs a session property to retrieve module."))
		
		return(self.session.site.module)
	
	def __getattribute__(self, attr):
		"""Calls the alias of an attribute if it did not exist."""
		
		# Check if we got here because a property raised an exception.
		try:
			return(object.__getattribute__(self, attr))
		except Exception as e:
			if not(isinstance(e, AttributeError) and str(e) == "'{0}' object has no attribute '{1}'".format(self.__class__.__name__, attr)):
				raise
			elif self.__class__._attr_recursion > MAX_ATTR_RECURSION:
				raise(AttributeError("Recursion too deep."))
			else:
				list_attr = self.module.get_attr_from_list("{0}.{1}".format(self.__class__.__name__, attr))
				if list_attr:
					self.__class__._attr_recursion += 1
					ret = getattr(self, list_attr)
					self.__class__._attr_recursion -= 1
					return(ret)
				else:
					raise
	
	def _find_instances(self, init=True):
		"""Checks whether there are any instances that are equal to the object.
		If so, the object's attributes are merged into the instance's.
		Otherwise, the object will be added to the list of instances."""
		
		if "session" not in self.__dict__:
			raise(ForumAPIException("Object needs a session property to merge instances."))
		
		if not self.session.track_instances:
			return
		
		self_idx = None
		other = None
		for idx in range(0, len(self.session.instances)):
			if type(self) == type(self.session.instances[idx]) and self == self.session.instances[idx]:
				if not init and id(self) == id(self.session.instances[idx]):
					# The object was already registered, and must be deleted
					# if another instance is found
					self_idx = idx
					continue
				
				if not other:
					# Always use the first applicable object
					other = self.session.instances[idx]
					if init or self_idx is not None:
						break
		
		if other is None:
			if init:
				self.session.instances.append(self)
		else:
			other.merge(self)
			
			if self_idx is not None:
				del self.session.instances[self_idx]
	
	def merge(self, other):
		"""Migrates attributes of the other instance into the current object."""
		
		other._merge(self)
		
		other.__dict__ = self.__dict__
	
	def _merge(self, other):
		"""Migrates attributes of the current object to the other instance. It
		should be overridden in inheriting classes in order to achieve this
		effect."""
	
	def _merge_list(self, other, attr):
		"""Helper function for _merge implementations to merge a list
		attribute."""
		
		self_list = getattr(self, attr)
		if self_list is not None:
			other_list = getattr(other, attr)
			if other_list is None:
				setattr(other, attr, [])
			
			for item in self_list:
				if item not in other_list:
					other_list.append(item)
				elif isinstance(item, Mergeable):
					try:
						other_item = other_list[other_list.index(item)]
						other_item.merge(item)
					except:
						pass
	
	def _merge_dict(self, other, attr):
		"""Helper function for _merge implementations to merge a dict
		attribute."""
		
		self_dict = getattr(self, attr)
		if self_dict is not None:
			other_dict = getattr(other, attr)
			if other_dict is None:
				setattr(other, attr, {})
			
			for key, item in self_dict.items():
				if key not in other_dict:
					other_dict[key] = item
				elif isinstance(item, Mergeable) and other_dict[key] == item:
					try:
						other_item[key].merge(item)
					except:
						pass
	
	def _merge_obj(self, other, attr):
		"""Helper function for _merge implementations to merge an object
		attribute."""
		
		self_obj = getattr(self, attr)
		if self_obj is not None:
			
			other_obj = getattr(other, attr)
			if other_obj is None:
				setattr(other, attr, self_obj)
			elif isinstance(other_obj, Mergeable):
				try:
					other_obj.merge(self_obj)
				except:
					pass
	
	def __eq__(self, other):
		"""Checks for equality between the object and an arbitrary value, and
		returns a boolean based on this.
		
		This function is used when checking for equality between two instances
		of the same class, and it should be overridden in inheriting classes.
		The function can also contain equality checks against values of other
		types, such integers for an object's ID."""
		
		return(NotImplemented)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __lt__(self, other):
		"""Checks for less-than inequality between the object and an arbitrary
		value, and returns a boolean based on this."""
		
		return(NotImplemented)
	
	def __gt__(self, other):
		lt = self.__lt__(other)
		if lt == NotImplemented:
			return(NotImplemented)
		else:
			return(not lt and not self.__eq__(other))
	
	def __le__(self, other):
		lt = self.__lt__(other)
		if lt == NotImplemented:
			return(NotImplemented)
		else:
			return(lt or self.__eq__(other))
	
	def __ge__(self, other):
		lt = self.__lt__(other)
		if lt == NotImplemented:
			return(NotImplemented)
		else:
			return(not lt)

class Language(ExtensibleObject):
	default_lang = "en"
	
	config_parser = ConfigParser()
	config_read = None
	
	def __new__(cls, *a, **kw):
		if cls.config_read is None:
			cls.config_read = cls.config_parser.read(data_file("lang", "cfg"))
		
		return(super().__new__(cls))
	
	def __init__(self, session, code=None, settings=None):
		self.session = session
		
		if code:
			self.code = code
		else:
			self.code = self.__class__.default_lang
		
		if settings is not None:
			self.settings = settings
		else:
			self.settings = self.__class__.get_settings(self.code)
		
		self._fallback = None
	
	@property
	def data(self):
		return({"code": self.code})
	
	@classmethod
	def get_settings(cls, lang):
		if cls.config_read is None:
			raise(LanguageError("Initialize a Language object first."))
		
		try:
			return(cls.config_parser.get_section(lang))
		except:
			return({})
	
	@property
	def fallback(self):
		if self._fallback is None:
			if "fallback" in self.settings:
				self._fallback = Language(self.session, self.settings["fallback"])
			elif self.code != self.__class__.default_lang:
				self._fallback = Language(self.session, self.__class__.default_lang)
			else:
				self._fallback = False
			
			return(self._fallback)
		else:
			return(self._fallback)
	
	def __getitem__(self, key):
		if isinstance(key, (int, slice)):
			return(tuple(self.settings.items())[key])
		elif key in self.settings:
			return(self.settings[key])
		elif self.fallback:
			return(self.fallback[key])
		else:
			raise(KeyError(key))
	
	def __contains__(self, key):
		if key in self.settings:
			return(True)
		elif self.fallback:
			return(key in self.fallback)
		else:
			return(False)
	
	def __eq__(self, other):
		return(self.code == other.code)
	
	def __str__(self):
		return(self.code)
	
	def __repr__(self):
		return("<Language code=\"{0}\"{1}>".format(self.code, " name=\"{0}\"".format(self["name"]) if "name" in self.settings else ""))

class Session(ExtensibleObject):
	"""Handles actions that are to be taken with the site, such as logging in
	and posting."""
	
	def __init__(self, site, *a, **kw):
		self.site = site
		
		self.instances = []
		
		self.user = None
		self.logged_in = None
		self.guest = False
		
		self.security_token = None
		
		if "cookies" in kw:
			self.cookies = kw["cookies"]
		else:
			self.cookies = {}
		
		self.cookie_file = None
		
		if "verbose" in kw:
			self.verbose = kw["verbose"]
		else:
			self.verbose = False
		
		if "request_delay" in kw:
			self.request_delay = kw["request_delay"]
		else:
			self.request_delay = 3
		
		if "track_instances" in kw:
			self.track_instances = kw["track_instances"]
		else:
			self.track_instances = True
		
		if "instances_file" in kw:
			self.instances_file = kw["instances_file"]
		else:
			self.instances_file = ""
		
		self._lang = None
		if "language" in kw:
			self.language = kw["language"]
		else:
			self.language = ""
		
		self.read_instances()
	
	@property
	def module(self):
		return(self.site.module)
	
	@property
	def data(self):
		return({
			"cookies": self.cookies,
			"verbose": self.verbose,
			"request_delay": self.request_delay,
			"track_instances": self.track_instances,
			"instances_file": self.instances_file,
			"language": self.language,
		})
	
	@property
	def lang(self):
		if self._lang is not None:
			return(self._lang)
		else:
			self._lang = Language(self, self.language)
			return(self._lang)
	
	@classmethod
	def from_options(cls, site, options):
		"""Given a `Site` and the `options` attribute from `Settings`,
		chooses the authentication scheme and returns a `Session`."""
		
		auth = options.auth
		pass_arg = options.password
		
		kw = options.__dict__.copy()
		
		password = None
		session = None
		
		if auth == "pwfile":
			password = open(data_file(pass_arg, "pw")).read()
		elif auth == "sessionfile":
			session_hash = open(data_file(pass_arg, "session")).read()
			session = cls(site, session_hash, **kw)
		elif auth == "hashfile":
			password_hash = open(data_file(pass_arg, "hash")).read()
			session = cls(site, options.user, password_hash, **kw)
		elif auth == "cookiefile":
			session = cls.from_cookie_file(site, pass_arg, kw)
		elif auth == "input":
			password = pass_arg
		elif auth == "session":
			session = cls(site, pass_arg, **kw)
			pass_arg = None
		elif auth == "hash":
			session = cls(site, options.user, pass_arg, **kw)
			pass_arg = None
		
		cookie_file = None
		if not session:
			# Initialize with raw password
			session = cls.from_login(site, options.user, password, kw)
			cookie_file = options.user
		elif pass_arg is not None:
			# Use pass_arg for cookie_file
			cookie_file = pass_arg
		
		if cookie_file:
			session.cookie_file = cookie_file
			if options.save_cookies:
				session.store_cookies(save=True)
		
		return(session)
	
	@classmethod
	def from_login(cls, site, username, password, kw):
		"""Using a username and a password, logs into the site, returning a
		session assumed to be authenticated. May raise a `SessionError` on
		login failure."""
		
		subclass = site.module.get_class(cls)
		if hasattr(subclass, "from_login"):
			return(subclass.from_login(site, username, password, kw))
		
		raise(SessionError("Login not supported by forum type."))
	
	@classmethod
	def from_cookie_file(cls, site, file, kw):
		"""Obtains cookie data from a file, returning an session assumed to be
		authenticated."""
		
		cookies = {}
		
		lines = open(data_file(file, "cookie")).read().split("\n")
		for line in lines:
			key,_,value = line.partition("=")
			cookies[key] = value
		
		return(cls(site, cookies=cookies, **kw))
	
	def close(self):
		"""Executes some operations when the session is going to end."""
		
		self.store_instances()
		
		hooks.run("session_close", **locals())
	
	def read_instances(self):
		"""Reads a list of instances from a pickle file."""
		
		if self.instances_file:
			for data in list(unpickle_data(self.instances_file)):
				if not(isinstance(data, dict) or "_class" in data):
					continue
				
				name = data["_class"]
				del data["_class"]
				
				try:
					if "_module" in data:
						c = getattr(self.site.module.get_attr(data["_module"]), name)
						del data["_module"]
					else:
						c = self.site.module.get_attr(name)
				except:
					continue
				
				if issubclass(c, ExtensibleObject):
					# Instances tracking will take care of appending it
					c(self, **data)
	
	_module_pattern = None
	
	@property
	def module_pattern(self):
		if self._module_pattern:
			return(self._module_pattern)
		else:
			self.__class__._module_pattern = re.compile(r"^forumapi(?:\.({0}))?(?:\.(.+))?$".format("|".join([re.escape(type) for type in forum_types])))
			return(self._module_pattern)
	
	def store_instances(self):
		"""Stores the current instances into a pickle file."""
		
		if self.instances_file:
			instances = []
			for obj in self.instances:
				data = obj.data
				if isinstance(data, dict):
					module = self.module_pattern.match(obj.__module__).group(2)
					if module:
						data["_module"] = module
					
					data["_class"] = obj.__class__.__name__
					instances.append(data)
			
			pickle_data(self.instances_file, instances)
	
	def set_cookies(self, cookies):
		"""Overwrites the current cookies with a dictionary of cookies."""
		
		self.cookies = cookies
	
	def store_cookies(self, response=None, save=False):
		"""Stores cookies.
		
		If this is given a `PageRequest` response, the cookies from that
		response's header will be obtained and added to the session's cookies.
		
		If this session was instantiated from data from a cookie file, and there
		are new cookies (or the `save` param is set to `True`), then that file
		will be updated with the current cookies."""
		
		if response is not None:
			cookies = get_cookies(response)
			if cookies:
				# Only save retrieved cookies if a file for them already exists
				save = os.access(data_file(self.cookie_file, "cookie"), os.W_OK)
				self.cookies.update(cookies)
		
		if save and self.cookie_file:
			with open(data_file(self.cookie_file, "cookie"), mode="w", encoding=self.site.encoding) as f:
				f.write("\n".join("{0}={1}".format(str(key), str(value)) for (key, value) in self.cookies.items()))
				f.close()
	
	def _get_headers(self):
		"""Returns a dictionary of headers to send in HTTP requests."""
		
		return({"Cookie": encode_cookies(self.cookies)})
	
	def get_page(self, page="", method=None, post_data=None):
		"""Requests a page given a relative path.
		
		Cookies are updated if possible, and a `PageRequest` is returned."""
		
		headers = self._get_headers()
		
		if page.startswith(self.site.url):
			page = page[len(self.site.url):]
		
		if page[0] == "/":
			page = page[1:]
		
		response = PageRequest(self.site.url + page, encoding=self.site.encoding, delay=self.request_delay, verbose=self.verbose, headers=headers, method=method, data=post_data)
		
		self.store_cookies(response)
		
		if self.guest == False and self.logged_in is None:
			path = urllib.parse.urlparse(response.response.geturl()).path[1:]
			logged_in = self.check_logged_in(response, path=path)
			if logged_in == True:
				self.logged_in = True
			elif logged_in != None:
				raise(SessionError("User is not logged in when we should."))
		
		return(response)
	
	def soup(self, markup, format="xhtml"):
		"""Returns a BeautifulSoup object with appropiate properties."""
		
		#return(BeautifulSoup(str(markup), fromEncoding=self.site.encoding, convertEntities=format))
		return(BeautifulSoup(str(markup), fromEncoding=self.site.encoding, convertEntities=format))
	
	def check_logged_in(self, response):
		"""Checks whether the user is logged in when they should. If this
		returns False, a `SessionError` will be raised."""
		
		return(True)
	
	def get_search_results(self, special=None, pages=1, *a, **kw):
		raise(NotImplementedException("Cannot use search in this forum type."))
	
	def get_threads(self, response, pages=1, forum=None):
		raise(NotImplementedException("Cannot retrieve threads in this forum type."))
	
	def retrieve_metadata(self, page=None, soup=None, loc=None, f=None, t=None, p=None):
		raise(NotImplementedException("Cannot retrieve metadata in this forum type."))
	
	def retrieve_powers(self, soup):
		raise(NotImplementedException("Cannot retrieve powers in this forum type."))
	
	def get_security_token(self, form_data=None, page=None, soup=None):
		raise(NotImplementedException("Cannot retrieve security token in this forum type."))
		
	def prepare_post(self, t=None, p=None, *a, **kw):
		raise(NotImplementedException("Cannot post in this forum type."))
	
	def make_post(self, message, title=None, t=None, p=None, *a, **kw):
		raise(NotImplementedException("Cannot post in this forum type."))
	
	def make_thread(self, message, title, forum, *a, **kw):
		raise(NotImplementedException("Cannot post threads in this forum type."))

class User(ExtensibleObject):
	pass

class Post(ExtensibleObject):
	pass

class Thread(ExtensibleObject):
	pass

class Forum(ExtensibleObject):
	pass

class PreparedPost(ExtensibleObject):
	def __init__(self, session, type=None, identifier=None, post_data={}, t=None, p=None, f=None, **kw):
		self.session = session
		self.posted = False
		
		self.type = type
		self.identifier = identifier
		
		self.post_data = copy(post_data)
		
		self.kw = kw
		
		self.thread = t
		self.post = p
		self.forum = f
	
	@property
	def data(self):
		data = {
			"type": self.type,
			"identified": self.identifier,
			"post_data": self.post_data,
			"t": _flatten_data(self.thread),
			"p": _flatten_data(self.post),
			"f": _flatten_data(self.forum),
		}
		data.update(_flatten_data(self.kw))
		return(data)
	
	def submit(self):
		raise(NotImplementedException("Cannot post in this forum type."))
	
	def __repr__(self):
		return("<PreparedPost \"{self.type}\"{0}>".format(" posted" if self.posted else "", self=self))
